查看原文
其他

【第1253期】柯里化函数应用

2018-04-22 葉河英 前端早读课

前言

今日早读文章由@葉河英投稿分享。

@叶河英,腾讯前端工程师,主要研发系统和电商平台项目

正文从这开始~

概述

理解柯里化函数,需要有闭包的基础,只有彻底理解闭包后才能理解柯里化,如果尚未理解闭包,建议阅读上文js引擎的执行过程(一);如果理解了闭包再研究柯里化函数,则会大大的加深你对闭包理解,并且更清楚的认识到闭包的应用场景,那么如果在面试时候问到闭包,你就可以侃侃而谈了;并且理解柯里化函数会在很大的程度上提升函数式编程的能力,轻松解决各种复杂的编程问题。

说了这么多柯里化的好处,接下来我们赶紧学习柯里化吧!

在维基百科和百度百科中,对柯里化的定义是这样的,如下:

柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

由以上定义,柯里化又可理解为部分求值,返回接受剩余参数且返回结果的新函数。想要应用柯里化,我们必须先理解柯里化的作用和特点,这里我总结为以下三点:

  • 参数复用 – 复用最初函数的第一个参数

  • 提前返回 – 返回接受余下的参数且返回结果的新函数

  • 延迟执行 – 返回新函数,等待执行

了解柯里化的作用特点后,我们可以简单理解为当一个函数需要提前处理并需要等待执行或者接受多个不同作用的参数时候,我们便可应用柯里化。单纯讲解概念难免抽象,接下来,我们具体分析柯里化函数的应用。

应用

如果为了分析柯里化函数而编造一些简单例子去分析,那么难免会体现不出来柯里化的作用,而且也会仅限于理解柯里化而不知道应该在什么场景下应用柯里化函数,所以这里直接用我们在编程中经常接触的例子进行柯里化函数封装,并以此来理解柯里化函数。在编程开发中,使用柯里化函数封装解决问题的例子主要有:

  • 兼容浏览器事件监听方法

  • 性能优化:防抖和节流

  • 兼容低版本IE的bind方法

本文主要对上面三个场景案例进行详细分析,案例难度大小为从上至下,全面介绍柯里化函数,希望能帮助大家理解柯里化。

事件监听

原生事件监听的方法在现代浏览器和IE浏览器会有兼容问题,解决该兼容性问题的方法是进行一层封装,若不考虑柯里化函数,我们正常情况下会像下面这样进行封装,如下:

/*
* @param    ele        Object      DOM元素对象 * @param    type       String      事件类型 * @param    fn         Function    事件处理函数 * @param    isCapture  Boolean     是否捕获 */

var addEvent = function(ele, type, fn, isCapture) {
   
if(window.addEventListener) {
       ele
.addEventListener(type, fn, isCapture)
   
} else if(window.attachEvent) {
       ele
.attachEvent("on" + type, fn)
   
}
}

该封装方法完全没有问题,但是有个唯一的缺陷就是,当我们每次调用addEvent方法时,都会执行一次if...else if...,进行一次兼容判断。其实每次都执行兼容判断是完全没有必要的,那有没有办法只做一次判断呢?这个时候,柯里化函数就派上用场了,如下:

var addEvent = (function() {
   
if(window.addEventListener) {
       
return function(ele, type, fn, isCapture) {
           ele
.addEventListener(type, fn, isCapture)
       
}
   
} else if(window.attachEvent) {
       
return function(ele, type, fn) {
            ele
.attachEvent("on" + type, fn)
       
}
   
}
})()

这个例子利用了柯里化提前返回和延迟执行的特点,如下:

  • 提前返回 – 使用函数立即调用进行了一次兼容判断(部分求值),返回兼容的事件绑定方法

  • 延迟执行 – 返回新函数,在新函数调用兼容的事件方法。等待addEvent新函数调用,延迟执行

这就是柯里化函数的基本用法 – 提前返回延迟执行,但是这里没有利用到柯里化参数复用的特点,接下来我们继续分析防抖和节流。

防抖和节流

在web开发中,页面高频率触发的事件非常多,例如scroll,resize,mousemove等等,但是浏览器页面渲染的帧频为60fps,意思就是每秒刷新60帧,每1000/60约等于16.7ms刷新一次帧。

我们试想一下,如果高频事件的触发频率过快,以大于或者远大于16.7ms/帧的频率触发,会出现什么问题?

事件触发频率大于浏览器的显示频率(16.7ms/帧),即浏览器显示跟不上事件触发的频率,若事件处理函数中涉及DOM操作,则会导致浏览器掉帧,继而导致动画断续显示,画面粘滞,在很大程度上影响用户体验。

如果在高频事件以大于16.7ms/帧的速度进行,并且在该事件处理函数中进行大量的计算或DOM操作,会出现什么问题?

由于该事件处理函数复杂且触发过于频繁,会导致上一次事件触发的操作计算无法在下一次事件触发前完成,则会使浏览器CPU使用率不断增加,继而造成浏览器卡顿甚至崩溃,如下图所示:

example

注:注意观察图片左边的控制台输出以及右边浏览器任务管理器的CPU使用率。

由上面的问题,我们可以知道,使用高频事件时必须先解决其潜在的问题,才能保证页面性能。针对以上问题,我们可用以下两点方法从根本上上解决问题,如下:

  • 高频事件处理函数,不应该含有复杂的操作,例如DOM操作和复杂计算(DOM操作一般会造成页面回流和重绘,使浏览器不断重新渲染页面,若有疑问可阅读上文 – 浏览器渲染过程)。

  • 控制高频事件的触发频率

控制高频事件的触发频率是关键点,但是事件触发是原生事件监听方法进行监听的,那么我们该如何控制?

如果我们能延迟事件处理函数的执行,那么就相当于控制了事件的触发频率,然后再通过保存执行状态来控制事件处理函数的执行,那么整个问题就可以迎刃而解了。

其中防抖和节流对高频事件进行优化的原理就是通过延迟执行,将多个间隔接近的函数执行合并成一次函数执行。下面将会详细讲解。

防抖(Debouncing)

针对高频事件,防抖就是将多个触发间隔接近的事件函数执行,合并成一次函数执行。

实现防抖的关键点主要有两个,如下:

  • 使用setTimeout延时器,传入的延迟时间,将事件处理函数延迟执行,并且通过事件触发频率与延迟时间值的比较,控制处理函数是否执行

  • 使用柯里化函数结合闭包的思想,将执行状态保存在闭包中,返回新函数,在新函数中通过执行状态控制是否在滚动时执行处理函数

实现代码如下:

/*
* @param    fn              Function    事件处理函数 * @param    delay           Number      延迟时间 * @param    isImmediate     Boolean     是否滚动时立刻执行 * @return   Function                    事件处理函数 */

var debounce = function(fn, delay, isImmediate) {
   
//使用闭包,保存执行状态,控制函数调用顺序
   
var timer;
   
return function() {
       
var _args = [].slice.call(arguments),
           context
= this;
       clearTimeout
(timer);
       
var _fn = function() {
           timer
= null;
           
if (!isImmediate) fn.apply(context, _args);
       
};
       
//是否滚动时立刻执行
       
var callNow = !timer && isImmediate;
       timer
= setTimeout(_fn, delay);
       
if(callNow) fn.apply(context, _args);
   
}
}

防抖技术使用如下:

var debounceScroll = debounce(function() {
   
//事件处理函数,滚动时进行的处理
}, 100)
window
.addEventListener("scroll", debounceScroll)

防抖技术仅靠传入延迟时间值的大小控制高频事件的触发频率,如果传入的延迟时间值比较大,那么就会出现一定的问题。例如当传入延迟时间为1000ms,那么当用户滚动速度大于1000ms/次时,则无论鼠标滚动多久都不会触发事件处理函数。因此防抖技术存在一定的缺陷,会不适用于某些场景,例如图片懒加载。这个时候节流就派上用场了。

节流(Throttle)

节流也是将多个触发间隔接近的事件函数执行,合并成一次函数执行,并且在指定的时间内至少执行一次事件处理函数。

节流实现原理跟防抖技术类似,但是比防抖多了一次函数执行判断,实现的关键点是:

  • 利用闭包存储了当前和上一次执行的时间戳,通过两次函数执行的时间差跟指定的延迟时间的比较,控制函数是否立刻执行

实现代码如下:

/*
* @param    fn          Function    事件处理函数 * @param    wait        Number      延迟时间 * @return   Function                事件处理函数 */

var throttle = function(fn, wait) {
   
var timer, previous, now, diff;
   
return function() {
       
var _args = [].slice.call(arguments),
           context
= this;
       
//储存当前时间戳
       now
= Date.now();
       
var _fn = function() {
           
//存储上一次执行的时间戳
           previous
= Date.now();
           timer
= null;
           fn
.apply(context, _args)
       
}
       clearTimeout
(timer)
       
if(previous !== undefined) {
           
//时间差
           diff
= now - previous;
           
if(diff >= wait) {
               fn
.apply(context, _args);
               previous
= now;
           
} else {
               timer
= setTimeout(_fn, wait);
           
}
       
}else{
           _fn
();
       
}
   
}
}

注:以上防抖和节流函数封装是根据个人理解进行封装的,若想对比不同的封装方法,建议阅读第三方underscore函数库中的throttle和debounce的实现方法,原理大致是一样的,但是封装思维稍有不同。

拓展:

以上的节流和防抖技术都是用setTimeout实现的,是否有其他的实现方案,性能是否会更好?

可直接使用浏览器帧频刷新自动调用的方法(requestAnimationFrame)实现,实现起来会更加简单,而且性能会更好,但是唯一缺点就是需要自行解决低版本的IE浏览器兼容问题,实现代码如下:

//解决requestAnimationFrame兼容问题
var raFrame = window.requestAnimationFrame ||
             window
.webkitRequestAnimationFrame ||
             window
.mozRequestAnimationFrame ||
             window
.oRequestAnimationFrame ||
             window
.msRequestAnimationFrame ||
             
function(callback) {
                 window
.setTimeout(callback, 1000 / 60);
             
};
//柯里化封装
var rafThrottle = function(fn) {
   
var isLocked;
   
return function() {
       
var context = this,
           _args
= arguments;
       
if(isLocked) return
       isLocked
= true;
       raFrame
(function() {
           isLocked
= false;
           fn
.apply(context, _args)
       
})
   
}
}

bind函数柯里化

函数的bind方法相信我们都不陌生,但是低版本的IE浏览器不兼容bind方法,想要继续在低版本的IE浏览器中使用bind方法,则需要我们自行封装bind方法,实现的关键点是:

  • bind方法改变this指向,却不会执行原函数,那么我们可利用柯里化延迟执行,参数复用和提前返回的特点,返回新函数,在新函数使用apply方法执行原函数

我们这里将bind方法封装分为两种情况,如下:

第一种:简单的bind方法封装(不考虑构造函数,仅用于普通函数),实现代码如下:

   if (!Function.prototype.bind) {
       
Function.prototype.bind = function(context) {
           
if(context.toString() !== "[object Object]" && context.toString() !== "[object Window]" ) {
               
throw TypeError("context is not a Object.")
           
}
       
var _this = this;
       
var args = [].slice.call(arguments, 1);
       
return function() {
           
var _args = [].slice.call(arguments);
           _this
.apply(context, _args.concat(args))
       
}
   
}
}

第二种:复杂情况(考虑bind的任何用法),这里直接使用MDN的bind兼容方法,如下

   if (!Function.prototype.bind) {
     
Function.prototype.bind = function(oThis) {
       
if (typeof this !== 'function') {
         
// closest thing possible to the ECMAScript 5
         
// internal IsCallable function
         
throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
       
}
   
var aArgs   = Array.prototype.slice.call(arguments, 1),
       fToBind
= this,
       fNOP    
= function() {},
       fBound  
= function() {
         
return fToBind.apply(this instanceof fNOP
               
? this
               
: oThis,
               
// 获取调用时(fBound)的传参.bind 返回的函数入参往往是这么传递的
                aArgs
.concat(Array.prototype.slice.call(arguments)));
       
};
   
// 维护原型关系
   
if (this.prototype) {
     
// Function.prototype doesn't have a prototype property
     fNOP
.prototype = this.prototype;
   
}
   fBound
.prototype = new fNOP();
   
return fBound;
 
};
};

要理解复杂的bind兼容方法,必须彻底理解以下四个基础知识:

  • js的原型对象

  • 构造函数使用new操作符的过程

  • this的指向问题

  • 熟悉bind方法的使用场景

围绕以上四个关键点思考,bind的封装思想便可理解,这里不做过多解释。

柯里化函数封装

分析了柯里化的各种使用场景,相信我们已经大概感受到柯里化的好处了 – 部分求值,将复杂问题分步求解,变得更简单化。这里我们可以尝试封装一个简单的柯里化函数,如下:

function createCurry(fn) {
   
if(typeof fn !== "function"){
       
throw TypeError("fn is not function.");
   
}
   
//复用第一个参数
   
var args = [].slice.call(arguments, 1);
   
//返回新函数
   
return function(){
       
//收集剩余参数
       
var _args = [].slice.call(arguments);
       
//返回结果
       
return fn.apply(this, args.concat(_args));
   
}
}

柯里化函数的特点如上注释所示:

  • 复用第一个参数

  • 返回新函数

  • 收集剩余参数

  • 返回结果

柯里化函数的简单例子应用,如下:

//add(19)(10, 20, 30),求该函数传递的参数和
var add = createCurry(function() {
   
//获取所有参数
   
var args = [].slice.call(arguments);
   
//返回累加结果
   
return args.reduce(function(accumulator, currentValue) {
       
return accumulator + currentValue
   
})
}, 19)
add
(10, 20, 30);    //79

总结

以上便是柯里化函数的基本应用以及原理,希望可以提升大家对柯里化函数以及函数式编程的理解,如有错误,敬请指正。

最后

js引擎的执行过程(一):https://heyingye.github.io/2018/03/19/js引擎的执行过程(一)/

浏览器渲染过程:ttps://heyingye.github.io/2018/03/13/浏览器渲染过程/

他曾分享过:

【第1250期】彻底理解浏览器的缓存机制

关于本文

作者:@葉河英
原文:https://heyingye.github.io/2018/04/20/柯里化函数应用/

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存